package server;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashSet;
import java.util.Random;
import java.util.Set;
import javax.swing.Timer;

/**
 * This class manages server-side socket connection.
 * The class listens to a give port for incoming TCP connection, and assign a
 * separate thread to communicate with each connection according to the protocol.
 * @author c8yanche
 */
public class ServerListener extends Thread {
//TODO: synchronize ThreadPool operation.
    //a set of all connection threads
    static private Set<SocketThread> socketThreadPool = new HashSet<SocketThread>();
    private final int listeningPort;
    private boolean isListening = false;
    private ServerSocket listeningSocket;
    private Random rand = new Random();
    private UserManager userMgr = new UserManager(); //FIXME
    public static final int DEF_TIME_OUT = 15000;

    /**
     * Create a server listener at a given port.
     * @param port the port that the server is listening on.
     */
    public ServerListener(int port) {
        this.listeningPort = port;
    }

    /**
     * Create a server listener at default port 7777.
     */
    public ServerListener() {
        this(7777);
    }

    /**
     * Toggles whether the server is listening for incoming connection.
     * @param isListening the server will listen for incoming connection if
     * this is set to true, will not if false.
     */
    public void setListening(Boolean isListening) {
        this.isListening = isListening;
    }

    /**
     * Broadcast a packet to all the client connected.
     * @param packet the packet to be broadcast.
     */
    public void broadcast(String packet) {
        for (SocketThread st : socketThreadPool) {
            try {
                st.send(packet);
            } catch (IOException e) {
                System.out.println("Error sending packet to client"); //FIXME
                continue;
            }
        }
    }

    @Override
    /**
     * The listening thread for any incoming connections.
     * <br>
     * If there is an incoming connection, a new thread is created to deal with
     * the connection, thus frees the main listening thread for other incoming
     * connections.
     */
    public void run() {
        try {
            listeningSocket = new ServerSocket(this.listeningPort);
            isListening = true;


            while (isListening) {
                //create a new thread, therefore free the main thread to listen
                //for more connections.
                SocketThread newThread =
                        new SocketThread(listeningSocket.accept());
                socketThreadPool.add(newThread);

                newThread.start();
                System.out.println("Connected to " + newThread.getAddr());
            }
        } catch (IOException ex) {
            System.out.printf("Cannot listen on port %d\n", this.listeningPort);
            System.exit(0);
        }

    }

    /**
     * A thread that communicates with a single client according to the protocol.
     */
    class SocketThread extends Thread implements ActionListener {

        private Socket socket;
        private String addr;
        private DataInputStream fromClient;
        private DataOutputStream toClient;
        private String userName, tempUserName, pubkey, md5sum;
        private boolean isListening, isAuthed, onAuth;
        private int timeOut;
        private Timer connTimer;

        /**
         * Create a socket thread that listens to a given socket and disconnects
         * after time out.
         * When no activity has been received from the client after timeOut, the
         * thread disconnects.
         * @param socket the socket that the thread listens to.
         * @param timeOut time out, in milliseconds.
         */
        SocketThread(Socket socket, int timeOut) {

            this.socket = socket;
            this.addr = this.socket.getInetAddress().getHostAddress() + ":" + Integer.toString(this.socket.getPort());
            this.userName = this.addr;
            this.isAuthed = false;
            this.onAuth = true;
            this.timeOut = timeOut;
            this.connTimer = new Timer(timeOut, this);
            this.connTimer.setRepeats(false);
        }

        /**
         * create a socket with default timeout specified in ServerListener.
         * @param socket the socket that the thread listens to.
         */
        SocketThread(Socket socket) {
            this(socket, DEF_TIME_OUT);
        }

        @Override
        /**
         * Create i/o stream from the socket and listens for incoming packets,
         * and communicate according to protocol.
         *
         */
        public void run() {
            String packet;
            try {
                fromClient = new DataInputStream(socket.getInputStream());
                toClient = new DataOutputStream(socket.getOutputStream());
                isListening = true;
                while (isListening) {
                    //timer, disconnects when timed out.
                    connTimer.start();
                    if ((packet = fromClient.readUTF()) == null) {
                        disconnect();
                        break;
                    }
                    connTimer.restart();
                    
                    if (packet.length() >= 4) {
                        packet = this.processInput(packet);
                        if (packet != null) {
                            this.send(packet);
                        }
                    } else {
                        System.out.println("malformed packet: " + packet);
                    }
                }
            } catch (IOException e) {
                //when the socket connection is broken, disconnect.
                disconnect();
                System.out.println(getRef() + " disconnected.");
            }
        }

        /**
         * Send a packet to the client through the socket.
         * @param packet the packet to be send.
         * @throws IOException when failed.
         */
        public void send(String packet) throws IOException {
            if (this.isAuthed || !packet.startsWith("PING") && !packet.startsWith("DATA")) {
                toClient.writeUTF(packet);
                System.out.println("Sending to " + getRef() + ": " + packet);
                if (packet.startsWith("LERR") || packet.startsWith("SERR")){
                    disconnect();
                }
            } else {
                if (packet.length() >= 8) {
                    packet = packet.substring(0, 8);
                }
                System.out.println("packet " + packet + "... to " + getRef() + " canceled.");
            }

        }

        /**
         * Get the address of the socket.
         * @return the inetAddress object of the client's address.
         */
        public String getAddr() {
            return this.addr;
        }

        /**
         * Disconnect from the client. This method forces to close the IO stream,
         * thereby terminates the listening thread throw exception handling.
         *
         */
        public void disconnect() { 
            try {
                fromClient.close();
                toClient.close();
                socket.close();
                this.isListening = false;
                socketThreadPool.remove(this);
                connTimer.stop();
            } catch (IOException e) {
                //duh.
            }
        }

        /**
         * The server side protocol.
         * The method processes incoming packet, and returns a suitable response.
         * If no response is needed, return null.
         * @param input packet received.
         * @return response packet. null if no response is needed.
         */
        public String processInput(String input) {
            System.out.println("Received from " + getRef() + ":" + input);
            if (input.startsWith("ACKN")) {
                return null;

            } else if (input.startsWith("PING")) {
                return "ACKN";

            } else if (input.startsWith("DISC")) {
                disconnect();

                //user login
            } else if (input.startsWith("USER")) {
                String uname = input.substring(4);
                //find the user in the database.
                if (userMgr.isValidUser(uname)) {
                    tempUserName = uname;
                    //generate a random pubkey.
                    pubkey = Long.toString(Math.abs(rand.nextLong()), 36);
                    //save md5 hash for further validation
                    md5sum = MD5Digest.toMD5(pubkey + userMgr.getPrivKey(uname));
                    System.out.println("md5sum=" + md5sum);
                    onAuth = true;
                    return "HKEY" + pubkey;
                } else {
                    onAuth = false;
                    return "LERRUser does not exist.";
                }

                //user sending password hash.
            } else if (input.startsWith("HASH")) {
                if (onAuth && input.substring(4).equalsIgnoreCase(md5sum)) {
                    userName = tempUserName;
                    isAuthed = true;
                    onAuth = false;
                    System.out.println(addr + " is authorized as " + userName);
                    return "AUTH";
                } else {
                    //login failed, password doesn't match
                    onAuth = false;
                    return "LERRInvalid password.";
                }

                //user responding to an emergency.
            } else if (input.startsWith("CMIT")) {
                if (isAuthed) {
                    //broadcast to every user.
                    broadcast("DATA" + input.substring(4));
                    //FIXME: XML import the event and call toString() for output.
                    System.out.println(getRef() + "responsed to " + input);
                    return null;
                } else {
                    return "LERRUnauthorized.";
                }
            } else {
                return "SERRUnknown package.";
            }
            //stop netbeans from complaining about missing returns
            return null;

        }

        /**
         * The method is called by the Timer when connection timed out.
         * It prints out a message and disconnect the client that times out.
         * @param e ActionEvent fired by the Timer.
         */
        public void actionPerformed(ActionEvent e) {
            System.out.println(getRef() + " timed out.");
            this.disconnect();
        }

        /**
         * Get a detailed identification of the client.
         * @return A String in the format of "userName@addr:port"
         */
        private String getRef() {
            return userName + "@" + addr;
        }
    }
}
